--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 29d91785fb3880c7677b4e31da560d95993ae089
Parents : 1184fa4
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T13:49:56-05:00
feat(call-management): update incoming call handling by implementing identity hash resolution for contacts, improving call rejection logic, and adding support for LXMF and LXST address matching in contact lookups
Changes
13 files changed, 438 insertions(+), 111 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 34fcff19..ff3d83da 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -4018,9 +4018,17 @@ class ReticulumMeshChat:
return
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
+ # Outgoing dial owns the line. Reject the inbound caller so they are
+ # not left ringing while we ignore the callback locally.
print(
- "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.",
+ "on_incoming_telephone_call: Rejecting as we are currently initiating an outgoing call.",
)
+ telephone = getattr(ctx.telephone_manager, "telephone", None)
+ if telephone:
+ threading.Timer(
+ 0.5,
+ lambda t=telephone: t.hangup(),
+ ).start()
return
caller_hash = caller_identity.hash.hex()
@@ -4054,15 +4062,7 @@ class ReticulumMeshChat:
ctx.config.telephone_allow_calls_from_contacts_only.get()
or ctx.config.block_all_from_strangers.get()
):
- contact = None
- try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(
- caller_hash
- )
- except Exception:
- # Treat lookup failure as non-contact to avoid accidentally allowing spam
- pass
- if not contact:
+ if not self._is_contact(caller_hash, context=ctx):
print(f"Rejecting incoming call from non-contact: {caller_hash}")
telephone = getattr(ctx.telephone_manager, "telephone", None)
if telephone:
@@ -4078,13 +4078,7 @@ class ReticulumMeshChat:
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
ch = caller_identity.hash.hex()
caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh"
- is_contact = False
- try:
- is_contact = (
- ctx.database.contacts.get_contact_by_identity_hash(ch) is not None
- )
- except Exception:
- pass
+ is_contact = self._is_contact(ch, context=ctx)
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
@@ -4181,16 +4175,11 @@ class ReticulumMeshChat:
is_filtered = False
if ctx.config.do_not_disturb_enabled.get():
is_filtered = True
- elif ctx.config.telephone_allow_calls_from_contacts_only.get():
- try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(
- remote_identity_hash,
- )
- if not contact:
- is_filtered = True
- except Exception:
- # Treat lookup failure as filtered to avoid leaking missed-call noise
- is_filtered = True
+ elif (
+ ctx.config.telephone_allow_calls_from_contacts_only.get()
+ or ctx.config.block_all_from_strangers.get()
+ ) and not self._is_contact(remote_identity_hash, context=ctx):
+ is_filtered = True
if not is_filtered:
AsyncUtils.run_async(
@@ -9435,16 +9424,14 @@ class ReticulumMeshChat:
if self.config.do_not_disturb_enabled.get():
# Don't report active call if DND is on and it's ringing
telephone_active_call = None
- elif self.config.telephone_allow_calls_from_contacts_only.get():
+ elif (
+ self.config.telephone_allow_calls_from_contacts_only.get()
+ or self.config.block_all_from_strangers.get()
+ ):
remote_identity = telephone_active_call.get_remote_identity()
if remote_identity:
caller_hash = remote_identity.hash.hex()
- contact = (
- self.database.contacts.get_contact_by_identity_hash(
- caller_hash,
- )
- )
- if not contact:
+ if not self._is_contact(caller_hash):
# Don't report active call if contacts-only is on and caller is not a contact
telephone_active_call = None
else:
@@ -9483,9 +9470,7 @@ class ReticulumMeshChat:
remote_icon = self.database.misc.get_user_icon(lxmf_destination_hash)
# Check if contact and get custom image
- contact = self.database.contacts.get_contact_by_identity_hash(
- remote_hash,
- )
+ contact = self._resolve_contact_for_hash(remote_hash)
custom_image = contact["custom_image"] if contact else None
active_call = {
@@ -9573,11 +9558,9 @@ class ReticulumMeshChat:
initiation_target_name = None
if initiation_target_hash:
try:
- contact = self.database.contacts.get_contact_by_identity_hash(
- initiation_target_hash,
- )
+ contact = self._resolve_contact_for_hash(initiation_target_hash)
if contact:
- initiation_target_name = contact.name
+ initiation_target_name = contact["name"]
except Exception:
pass
@@ -9755,9 +9738,7 @@ class ReticulumMeshChat:
if tele_hash:
d["remote_telephony_hash"] = tele_hash
- contact = self.database.contacts.get_contact_by_identity_hash(
- remote_identity_hash,
- )
+ contact = self._resolve_contact_for_hash(remote_identity_hash)
d["is_contact"] = contact is not None
if contact:
d["contact_image"] = contact.get("custom_image")
@@ -10595,28 +10576,69 @@ class ReticulumMeshChat:
status=400,
)
- if not remote_identity_hash:
- # Try to derive identity from LXMF or LXST address
- lookup_hash = lxmf_address or lxst_address
- if lookup_hash:
- announce = self.database.announces.get_announce_by_hash(lookup_hash)
- if announce:
- remote_identity_hash = announce.get("identity_hash")
- else:
- # try to recall identity from RNS
- ident = self.recall_identity(lookup_hash)
- if ident:
- remote_identity_hash = ident.hash.hex()
+ # Normalize: chat UI often posts an LXMF destination hash as
+ # remote_identity_hash. Prefer the real identity hash when known so
+ # incoming-call policy (identity hash) matches saved contacts.
+ provided_hash = remote_identity_hash
+ lookup_hash = remote_identity_hash or lxmf_address or lxst_address
+ if lookup_hash:
+ announce = self.database.announces.get_announce_by_hash(lookup_hash)
+ if announce and announce.get("identity_hash"):
+ remote_identity_hash = announce.get("identity_hash")
+ if not lxmf_address and announce.get("aspect") == "lxmf.delivery":
+ lxmf_address = announce.get("destination_hash") or lookup_hash
+ if not lxst_address and announce.get("aspect") == "lxst.telephony":
+ lxst_address = announce.get("destination_hash") or lookup_hash
+ else:
+ ident = self.recall_identity(lookup_hash)
+ if ident:
+ remote_identity_hash = ident.hash.hex()
if not remote_identity_hash:
- # Fallback: use the provided lookup hash directly as identity hash
- remote_identity_hash = lxmf_address or lxst_address
+ remote_identity_hash = lxmf_address or lxst_address or provided_hash
if not remote_identity_hash:
return web.json_response(
{"message": "Identity hash is required or could not be derived"},
status=400,
)
+ # If the client only supplied a destination hash, keep it on the
+ # matching address field so lookups by either form succeed.
+ if provided_hash and provided_hash != remote_identity_hash:
+ if not lxmf_address:
+ lxmf_announce = self.database.announces.get_announce_by_hash(
+ provided_hash,
+ )
+ if lxmf_announce and lxmf_announce.get("aspect") == "lxmf.delivery":
+ lxmf_address = provided_hash
+ elif not lxst_address:
+ lxst_announce = self.database.announces.get_announce_by_hash(
+ provided_hash,
+ )
+ if (
+ lxst_announce
+ and lxst_announce.get("aspect") == "lxst.telephony"
+ ):
+ lxst_address = provided_hash
+ else:
+ # Default: treat unknown destination-shaped hashes as LXMF
+ lxmf_address = lxmf_address or provided_hash
+
+ if not lxmf_address:
+ try:
+ lxmf_address = self.get_lxmf_destination_hash_for_identity_hash(
+ remote_identity_hash,
+ )
+ except Exception:
+ pass
+ if not lxst_address:
+ try:
+ lxst_address = self.get_lxst_telephony_hash_for_identity_hash(
+ remote_identity_hash,
+ )
+ except Exception:
+ pass
+
self.database.contacts.add_contact(
name,
remote_identity_hash,
@@ -10663,7 +10685,7 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/contacts/check/{identity_hash}")
async def telephone_contacts_check(request):
identity_hash = request.match_info["identity_hash"]
- contact = self.database.contacts.get_contact_by_identity_hash(identity_hash)
+ contact = self._resolve_contact_for_hash(identity_hash)
return web.json_response(
{
"is_contact": contact is not None,
@@ -19091,15 +19113,82 @@ class ReticulumMeshChat:
background_colour,
)
- def _is_contact(self, source_hash: str, context=None) -> bool:
+ def _related_hashes_for_contact_lookup(self, source_hash: str, context=None):
+ """Collect identity/LXMF/LXST hashes that may identify the same peer."""
ctx = context or self.current_context
+ related = []
+ seen = set()
+
+ def add(value):
+ if not value or not isinstance(value, str):
+ return
+ normalized = normalize_hex_identifier(value)
+ if not normalized or normalized in seen:
+ return
+ seen.add(normalized)
+ related.append(normalized)
+
+ add(source_hash)
if not ctx or not ctx.database:
- return False
+ return related
+
try:
- contact = ctx.database.contacts.get_contact_by_identity_hash(source_hash)
- return contact is not None
+ announce = ctx.database.announces.get_announce_by_hash(source_hash)
+ if announce:
+ add(announce.get("identity_hash"))
+ add(announce.get("destination_hash"))
+ identity_hash = announce.get("identity_hash")
+ if identity_hash:
+ for other in ctx.database.announces.get_announces_by_identity_hash(
+ identity_hash,
+ ):
+ add(other.get("destination_hash"))
+ add(other.get("identity_hash"))
+ else:
+ for other in ctx.database.announces.get_announces_by_identity_hash(
+ source_hash,
+ ):
+ add(other.get("destination_hash"))
+ add(other.get("identity_hash"))
except Exception:
- return False
+ pass
+
+ try:
+ lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(source_hash)
+ add(lxmf_hash)
+ except Exception:
+ pass
+
+ try:
+ lxst_hash = self.get_lxst_telephony_hash_for_identity_hash(source_hash)
+ add(lxst_hash)
+ except Exception:
+ pass
+
+ return related
+
+ def _resolve_contact_for_hash(self, source_hash: str, context=None):
+ """Resolve a contact for an identity or destination hash.
+
+ Contacts are often saved with an LXMF destination hash as
+ ``remote_identity_hash`` (from chat UI). Incoming calls provide the
+ caller's identity hash. Bridge those forms via announces and derived
+ destination hashes so contacts-only call policy works.
+ """
+ ctx = context or self.current_context
+ if not ctx or not ctx.database or not source_hash:
+ return None
+ try:
+ related = self._related_hashes_for_contact_lookup(source_hash, context=ctx)
+ return ctx.database.contacts.get_contact_by_identity_hash(
+ source_hash,
+ related_hashes=related,
+ )
+ except Exception:
+ return None
+
+ def _is_contact(self, source_hash: str, context=None) -> bool:
+ return self._resolve_contact_for_hash(source_hash, context=context) is not None
def _encode_pcm_wav_to_ogg_opus(self, wav_bytes: bytes) -> bytes | None:
"""Encode a WAV/PCM payload into an OGG/Opus byte string.
diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py
index 171b1383..af3bbcf5 100644
--- a/meshchatx/src/backend/database/contacts.py
+++ b/meshchatx/src/backend/database/contacts.py
@@ -132,8 +132,38 @@ class ContactsDAO:
def delete_contact(self, contact_id):
self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
- def get_contact_by_identity_hash(self, remote_identity_hash):
+ def get_contact_by_identity_hash(self, remote_identity_hash, related_hashes=None):
+ """Match a contact by identity, LXMF, or LXST hash.
+
+ ``related_hashes`` may include derived destination hashes for the same
+ peer so callers that only know an identity hash still match contacts
+ that were saved with an LXMF or LXST destination hash as the primary key.
+ Matching is case-insensitive. Hex-only forms are also tried so UUID-style
+ separators still match stored RNS hashes.
+ """
+ if not remote_identity_hash and not related_hashes:
+ return None
+
+ candidates = []
+ for value in (remote_identity_hash, *(related_hashes or ())):
+ if not value or not isinstance(value, str):
+ continue
+ lowered = value.strip().lower()
+ if lowered and lowered not in candidates:
+ candidates.append(lowered)
+ hex_only = "".join(c for c in lowered if c in "0123456789abcdef")
+ if hex_only and hex_only not in candidates:
+ candidates.append(hex_only)
+ if not candidates:
+ return None
+
+ placeholders = ", ".join("?" for _ in candidates)
return self.provider.fetchone(
- "SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
- (remote_identity_hash, remote_identity_hash, remote_identity_hash),
+ f"""
+ SELECT * FROM contacts
+ WHERE lower(remote_identity_hash) IN ({placeholders})
+ OR lower(lxmf_address) IN ({placeholders})
+ OR lower(lxst_address) IN ({placeholders})
+ """,
+ tuple(candidates) * 3,
)
diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index 5c85ce63..29b7c6aa 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -171,7 +171,23 @@ class VoicemailManager:
RNS.log("Voicemail: Voicemail is disabled", RNS.LOG_DEBUG)
return
- if self.db and self.db.misc.is_destination_blocked(caller_identity.hash.hex()):
+ caller_hash = caller_identity.hash.hex()
+ is_blocked = False
+ if self.db:
+ try:
+ if self.db.misc.is_destination_blocked(caller_hash):
+ is_blocked = True
+ else:
+ for ann in self.db.announces.get_announces_by_identity_hash(
+ caller_hash,
+ ):
+ dest = ann.get("destination_hash")
+ if dest and self.db.misc.is_destination_blocked(dest):
+ is_blocked = True
+ break
+ except Exception:
+ is_blocked = False
+ if is_blocked:
RNS.log(
f"Voicemail: Caller {RNS.prettyhexrep(caller_identity.hash)} is blocked; skipping auto-answer",
RNS.LOG_DEBUG,
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 0e334beb..97159768 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1267,7 +1267,11 @@ export default {
if (this.config?.do_not_disturb_enabled) {
return;
}
- if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) {
+ if (
+ (this.config?.telephone_allow_calls_from_contacts_only ||
+ this.config?.block_all_from_strangers) &&
+ !json.is_contact
+ ) {
return;
}
if (this.initiationStatus) {
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index d3551935..00af0f74 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2422,6 +2422,8 @@ export default {
contactForm: {
name: "",
remote_identity_hash: "",
+ lxmf_address: "",
+ lxst_address: "",
},
searchDebounceTimeout: null,
isVoicemailSettingsExpanded: false,
@@ -3585,8 +3587,9 @@ export default {
this.editingContact = null;
this.contactForm = {
name: entry.remote_identity_name || "",
- remote_identity_hash:
- entry.remote_telephony_hash || entry.remote_destination_hash || entry.remote_identity_hash,
+ remote_identity_hash: entry.remote_identity_hash || "",
+ lxmf_address: entry.remote_destination_hash || "",
+ lxst_address: entry.remote_telephony_hash || "",
preferred_ringtone_id: null,
};
this.isContactModalOpen = true;
@@ -4245,22 +4248,32 @@ export default {
}
let hashToCall = identityHash.trim();
- // Accept lxmf:// URIs or pasted text; extract first 64-char hex
- const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/);
+ // Accept lxmf:// URIs or pasted text; RNS truncated hashes are 32 hex chars
+ const hexMatch = hashToCall.match(/[0-9a-fA-F]{32,64}/);
if (hexMatch) {
- hashToCall = hexMatch[0];
+ hashToCall = hexMatch[0].slice(0, 32);
}
hashToCall = hashToCall.toLowerCase();
// Try to resolve name from contacts
const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase());
if (contact) {
- hashToCall = contact.remote_identity_hash;
+ hashToCall =
+ contact.remote_identity_hash ||
+ contact.remote_telephony_hash ||
+ contact.lxst_address ||
+ contact.lxmf_address;
}
// Provide immediate feedback
this.destinationHash = hashToCall;
- const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall);
+ const targetContact = this.contacts.find(
+ (c) =>
+ c.remote_identity_hash === hashToCall ||
+ c.lxmf_address === hashToCall ||
+ c.lxst_address === hashToCall ||
+ c.remote_telephony_hash === hashToCall
+ );
this.initiationTargetHash = hashToCall;
this.initiationTargetName = targetContact ? targetContact.name : null;
this.activeTab = "phone";
diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index 1577759d..fc16302c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -228,7 +228,8 @@ export default {
// create contact first
await window.api.post("/api/v1/telephone/contacts", {
name: this.peer.display_name,
- remote_identity_hash: this.peer.destination_hash,
+ remote_identity_hash: this.peer.identity_hash || undefined,
+ lxmf_address: this.peer.destination_hash,
is_telemetry_trusted: true,
});
await this.fetchContact();
@@ -239,7 +240,7 @@ export default {
this.contact.is_telemetry_trusted = newStatus;
}
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: this.peer.destination_hash,
+ remote_identity_hash: this.peer.identity_hash || this.peer.destination_hash,
is_telemetry_trusted: newStatus,
});
DialogUtils.alert(
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 0a55623a..ace54d44 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -3143,11 +3143,12 @@ export default {
await window.api.post("/api/v1/telephone/contacts", {
name: displayName,
lxmf_address: hash,
+ remote_identity_hash: this.selectedPeer.identity_hash || undefined,
});
this.isStrangerPeer = false;
this.strangerBannerDismissed = true;
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: hash,
+ remote_identity_hash: this.selectedPeer.identity_hash || hash,
});
this.$emit("reload-conversations");
ToastUtils.success(this.$t("contacts.contact_added"));
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 550ed355..d0100f1e 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -1275,13 +1275,13 @@ export default {
async toggleTelemetryTrust(hash) {
const contact = this.contextMenu.targetContact;
const newStatus = !contact?.is_telemetry_trusted;
+ const conv = this.conversations.find((c) => c.destination_hash === hash);
try {
if (!contact) {
- // find display name from conversations
- const conv = this.conversations.find((c) => c.destination_hash === hash);
await window.api.post("/api/v1/telephone/contacts", {
name: conv?.display_name || hash.substring(0, 8),
- remote_identity_hash: hash,
+ remote_identity_hash: conv?.identity_hash || undefined,
+ lxmf_address: hash,
is_telemetry_trusted: true,
});
} else {
@@ -1290,7 +1290,7 @@ export default {
});
}
GlobalEmitter.emit("contact-updated", {
- remote_identity_hash: hash,
+ remote_identity_hash: conv?.identity_hash || hash,
is_telemetry_trusted: newStatus,
});
this.contextMenu.show = false;
diff --git a/tests/backend/test_contact_hash_resolution.py b/tests/backend/test_contact_hash_resolution.py
new file mode 100644
index 00000000..8fb38a88
--- /dev/null
+++ b/tests/backend/test_contact_hash_resolution.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Contact resolution across identity / LXMF / LXST hash forms."""
+
+from unittest.mock import MagicMock
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+IDENTITY = "a1" * 16
+LXMF = "b2" * 16
+LXST = "c3" * 16
+
+
+def _app_with_db(contact_row=None, announces=None):
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ ctx.database = MagicMock()
+ ctx.database.announces.get_announce_by_hash.return_value = None
+ ctx.database.announces.get_announces_by_identity_hash.return_value = announces or []
+ ctx.database.contacts.get_contact_by_identity_hash.return_value = contact_row
+ app.current_context = ctx
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF)
+ app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST)
+ return app
+
+
+def test_related_hashes_include_announced_and_derived_destinations():
+ app = _app_with_db(
+ announces=[
+ {
+ "destination_hash": LXMF,
+ "identity_hash": IDENTITY,
+ "aspect": "lxmf.delivery",
+ },
+ ],
+ )
+ related = app._related_hashes_for_contact_lookup(IDENTITY)
+ assert IDENTITY in related
+ assert LXMF in related
+ assert LXST in related
+
+
+def test_is_contact_true_when_saved_under_lxmf_primary_key():
+ contact = {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
+
+ def lookup(primary, related_hashes=None):
+ keys = {primary, *(related_hashes or ())}
+ if LXMF in keys:
+ return contact
+ return None
+
+ app = _app_with_db()
+ app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = (
+ lookup
+ )
+ app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
+ {
+ "destination_hash": LXMF,
+ "identity_hash": IDENTITY,
+ "aspect": "lxmf.delivery",
+ },
+ ]
+
+ assert app._is_contact(IDENTITY) is True
+ assert app._resolve_contact_for_hash(IDENTITY)["name"] == "Friend"
+
+
+def test_is_contact_false_for_unknown_peer():
+ app = _app_with_db(contact_row=None)
+ assert app._is_contact(IDENTITY) is False
diff --git a/tests/backend/test_contacts_display_name_semantics.py b/tests/backend/test_contacts_display_name_semantics.py
index ccdb74e3..36cf66bc 100644
--- a/tests/backend/test_contacts_display_name_semantics.py
+++ b/tests/backend/test_contacts_display_name_semantics.py
@@ -288,6 +288,24 @@ class TestContactsEdgeCases:
assert result is not None
assert result["name"] == "Via LXST"
+ def test_get_contact_by_identity_hash_case_insensitive(self, contacts_dao):
+ contacts_dao.add_contact("Case", "AABB" + "cc" * 14)
+ result = contacts_dao.get_contact_by_identity_hash("aabb" + "CC" * 14)
+ assert result is not None
+ assert result["name"] == "Case"
+
+ def test_get_contact_by_identity_hash_related_hashes(self, contacts_dao):
+ identity = "a1" * 16
+ lxmf = "b2" * 16
+ contacts_dao.add_contact("SavedAsLxmf", lxmf, lxmf_address=lxmf)
+ result = contacts_dao.get_contact_by_identity_hash(
+ identity,
+ related_hashes=[lxmf],
+ )
+ assert result is not None
+ assert result["name"] == "SavedAsLxmf"
+ assert result["remote_identity_hash"] == lxmf
+
def test_delete_nonexistent_contact(self, contacts_dao):
contacts_dao.delete_contact(99999)
diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py
index 9b8df110..172fa013 100644
--- a/tests/backend/test_incoming_call_policy.py
+++ b/tests/backend/test_incoming_call_policy.py
@@ -9,6 +9,7 @@ import pytest
from meshchatx.meshchat import ReticulumMeshChat
CALLER_HASH_HEX = "a1" * 16
+LXMF_DEST_HEX = "b2" * 16
def _caller_identity():
@@ -27,10 +28,15 @@ def policy_app():
ctx.telephone_manager = tm
ctx.config = MagicMock()
ctx.database = MagicMock()
+ ctx.database.announces.get_announce_by_hash.return_value = None
+ ctx.database.announces.get_announces_by_identity_hash.return_value = []
ctx.voicemail_manager = MagicMock()
app.current_context = ctx
app.is_destination_blocked = MagicMock(return_value=False)
app.websocket_broadcast = MagicMock()
+ app.get_name_for_identity_hash = MagicMock(return_value="Caller")
+ app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None)
+ app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=None)
return app
@@ -56,8 +62,8 @@ def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app):
_run_incoming(policy_app, caller)
- policy_app.telephone_manager.telephone.hangup.assert_called_once()
- policy_app.voicemail_manager.handle_incoming_call.assert_not_called()
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
def test_incoming_dnd_rejects_before_contact_check(policy_app):
@@ -115,9 +121,9 @@ def test_contacts_only_rejects_non_contact_uses_identity_lookup(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
+ call_kwargs = policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_args
+ assert call_kwargs[0][0] == CALLER_HASH_HEX
policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@@ -139,13 +145,10 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- # Called twice: once for policy check, once for websocket broadcast is_contact flag
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
assert (
policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count
- == 2
+ >= 1
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
@@ -154,6 +157,42 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async.assert_called_once()
+def test_contacts_only_accepts_contact_saved_under_lxmf_destination(policy_app):
+ """Chat UI often stores LXMF destination hash as remote_identity_hash."""
+ policy_app.is_destination_blocked.return_value = False
+ policy_app.config.do_not_disturb_enabled.get.return_value = False
+ policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
+ policy_app.config.block_all_from_strangers.get.return_value = False
+ policy_app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
+ {
+ "destination_hash": LXMF_DEST_HEX,
+ "identity_hash": CALLER_HASH_HEX,
+ "aspect": "lxmf.delivery",
+ },
+ ]
+ policy_app.get_lxmf_destination_hash_for_identity_hash.return_value = LXMF_DEST_HEX
+
+ def lookup(primary, related_hashes=None):
+ keys = {primary, *(related_hashes or ())}
+ if LXMF_DEST_HEX in keys or CALLER_HASH_HEX in keys:
+ return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF_DEST_HEX}
+ return None
+
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup
+
+ caller = _caller_identity()
+
+ with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
+ async_utils.run_async = MagicMock()
+ _run_incoming(policy_app, caller)
+
+ policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
+ caller
+ )
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
+ async_utils.run_async.assert_called_once()
+
+
def test_block_all_strangers_uses_same_contact_gate(policy_app):
policy_app.is_destination_blocked.return_value = False
policy_app.config.do_not_disturb_enabled.get.return_value = False
@@ -177,9 +216,7 @@ def test_block_all_strangers_uses_same_contact_gate(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
@@ -188,6 +225,7 @@ def test_when_policy_off_stranger_rings(policy_app):
policy_app.config.do_not_disturb_enabled.get.return_value = False
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = False
policy_app.config.block_all_from_strangers.get.return_value = False
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
caller = _caller_identity()
@@ -195,24 +233,32 @@ def test_when_policy_off_stranger_rings(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
- # Called once for websocket broadcast is_contact flag even when policy is off
- policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
- CALLER_HASH_HEX,
- )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
async_utils.run_async.assert_called_once()
-def test_ignores_incoming_while_outgoing_initiation(policy_app):
+def test_rejects_incoming_while_outgoing_initiation(policy_app):
policy_app.current_context.telephone_manager.initiation_status = "Dialing..."
caller = _caller_identity()
- with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
- async_utils.run_async = MagicMock()
- _run_incoming(policy_app, caller)
+ with patch("meshchatx.meshchat.threading.Timer") as mock_timer:
+ def run_timer(delay, fn):
+ fn()
+ t = MagicMock()
+ t.start = MagicMock()
+ return t
+
+ mock_timer.side_effect = run_timer
+
+ with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
+ async_utils.run_async = MagicMock()
+ _run_incoming(policy_app, caller)
+
+ policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@@ -224,14 +270,14 @@ def test_uses_passed_context_not_app_current_context(policy_app):
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
policy_app.config.block_all_from_strangers.get.return_value = False
- # current_context has no contact
policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
- # But a different ctx passed to the method DOES have the contact
other_ctx = MagicMock()
other_ctx.telephone_manager = policy_app.current_context.telephone_manager
other_ctx.config = policy_app.config
other_ctx.database = MagicMock()
+ other_ctx.database.announces.get_announce_by_hash.return_value = None
+ other_ctx.database.announces.get_announces_by_identity_hash.return_value = []
other_ctx.database.contacts.get_contact_by_identity_hash.return_value = {
"id": 1,
"name": "Friend",
@@ -244,10 +290,6 @@ def test_uses_passed_context_not_app_current_context(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller, ctx=other_ctx)
- # Called twice: once for policy check, once for websocket broadcast is_contact flag
- other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_with(
- CALLER_HASH_HEX,
- )
- assert other_ctx.database.contacts.get_contact_by_identity_hash.call_count == 2
+ other_ctx.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py
index 4e83f17d..7f01c50e 100644
--- a/tests/backend/test_voicemail_manager_extended.py
+++ b/tests/backend/test_voicemail_manager_extended.py
@@ -201,6 +201,27 @@ def test_voicemail_session_sets_active_flag(mock_deps, temp_dir):
assert mock_tel_manager.is_voicemail_session_active is True
+def test_handle_incoming_skips_when_lxmf_destination_blocked(mock_deps, temp_dir):
+ mock_db = MagicMock()
+ mock_config = MagicMock()
+ mock_config.voicemail_enabled.get.return_value = True
+ mock_tel_manager = MagicMock()
+ vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
+
+ caller_hash = "a1" * 16
+ lxmf_dest = "b2" * 16
+ mock_caller = MagicMock()
+ mock_caller.hash = bytes.fromhex(caller_hash)
+ mock_db.misc.is_destination_blocked.side_effect = lambda h: h == lxmf_dest
+ mock_db.announces.get_announces_by_identity_hash.return_value = [
+ {"destination_hash": lxmf_dest, "identity_hash": caller_hash},
+ ]
+
+ with patch("threading.Thread") as mock_thread:
+ vm.handle_incoming_call(mock_caller)
+ mock_thread.assert_not_called()
+
+
def test_stop_recording_clears_active_flag(mock_deps, temp_dir):
mock_db = MagicMock()
mock_config = MagicMock()
diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index bd51e1ea..73a893d8 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -260,6 +260,28 @@ describe("CallPage.vue", () => {
});
});
+ it("call extracts 32-char RNS hash from pasted text", async () => {
+ const wrapper = mountCallPage();
+ await wrapper.vm.$nextTick();
+ const hash32 = "ab".repeat(16);
+ await wrapper.vm.call(`Call me at ${hash32} please`);
+ expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`);
+ });
+
+ it("addContactFromHistory prefers identity hash over destination hashes", async () => {
+ const wrapper = mountCallPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.addContactFromHistory({
+ remote_identity_name: "Sam",
+ remote_identity_hash: "aa".repeat(16),
+ remote_destination_hash: "bb".repeat(16),
+ remote_telephony_hash: "cc".repeat(16),
+ });
+ expect(wrapper.vm.contactForm.remote_identity_hash).toBe("aa".repeat(16));
+ expect(wrapper.vm.contactForm.lxmf_address).toBe("bb".repeat(16));
+ expect(wrapper.vm.contactForm.lxst_address).toBe("cc".repeat(16));
+ });
+
it("toggleTelephoneAnnounceEnabled patches config", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────